--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit f5531cc3f063c4383a8468c8c735cbc6b2a7f6e3
Parents : 85301cb
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-06-05T21:35:13-05:00
feat(messages): update message import functionality with file upload support and improved error handling
Changes
9 files changed, 594 insertions(+), 56 deletions(-)
Diff
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index d9d874aa..cbd430fe 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -7010,17 +7010,107 @@ class ReticulumMeshChat:
m["lxmf_icon"] = icons[h]
return web.json_response({"messages": messages_list})
+ def _message_import_response(result):
+ imported = result["imported"]
+ skipped = result["skipped"]
+ errors = result["errors"]
+ if imported == 0 and errors:
+ return web.json_response(
+ {
+ "error": errors[0]["error"],
+ "imported": imported,
+ "skipped": skipped,
+ "errors": errors,
+ },
+ status=400,
+ )
+ response = {
+ "message": f"Successfully imported {imported} messages",
+ "imported": imported,
+ "skipped": skipped,
+ }
+ if errors:
+ response["errors"] = errors
+ return web.json_response(response)
+
+ def _parse_message_import_payload(payload):
+ if isinstance(payload, list):
+ return payload
+ if isinstance(payload, dict):
+ messages = payload.get("messages", [])
+ return [] if messages is None else messages
+ return None
+
# maintenance - import messages
@routes.post("/api/v1/maintenance/messages/import")
async def maintenance_import_messages(request):
try:
data = await request.json()
- messages = data.get("messages", [])
- for msg in messages:
- self.database.messages.upsert_lxmf_message(msg)
- return web.json_response(
- {"message": f"Successfully imported {len(messages)} messages"},
+ messages = _parse_message_import_payload(data)
+ if messages is None or not isinstance(messages, list):
+ return web.json_response(
+ {"error": "messages must be an array"},
+ status=400,
+ )
+ if self.database is None:
+ return web.json_response(
+ {"error": "No active identity database"},
+ status=400,
+ )
+
+ result = await asyncio.to_thread(
+ self.database.messages.import_lxmf_messages,
+ messages,
+ )
+ return _message_import_response(result)
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=400)
+
+ @routes.post("/api/v1/maintenance/messages/import-file")
+ async def maintenance_import_messages_file(request):
+ try:
+ if self.database is None:
+ return web.json_response(
+ {"error": "No active identity database"},
+ status=400,
+ )
+
+ reader = await request.multipart()
+ field = await reader.next()
+ if field is None or field.name != "file":
+ return web.json_response(
+ {"error": "Import file is required"},
+ status=400,
+ )
+
+ chunks = []
+ while True:
+ chunk = await field.read_chunk(size=1024 * 1024)
+ if not chunk:
+ break
+ chunks.append(chunk)
+ raw = b"".join(chunks)
+
+ try:
+ payload = json.loads(raw)
+ except json.JSONDecodeError as exc:
+ return web.json_response(
+ {"error": f"Invalid JSON: {exc}"},
+ status=400,
+ )
+
+ messages = _parse_message_import_payload(payload)
+ if messages is None or not isinstance(messages, list):
+ return web.json_response(
+ {"error": "messages must be an array"},
+ status=400,
+ )
+
+ result = await asyncio.to_thread(
+ self.database.messages.import_lxmf_messages,
+ messages,
)
+ return _message_import_response(result)
except Exception as e:
return web.json_response({"error": str(e)}, status=400)
@@ -13922,8 +14012,8 @@ class ReticulumMeshChat:
# create and run web app
app = web.Application(
- client_max_size=1024 * 1024 * 50,
- ) # allow uploading files up to 50mb
+ client_max_size=1024 * 1024 * 256,
+ ) # allow large message exports with embedded attachments
# setup session storage
# aiohttp_session.setup must be called before other middlewares that use sessions
diff --git a/meshchatx/src/backend/database/messages.py b/meshchatx/src/backend/database/messages.py
index 09420cc3..5f456cda 100644
--- a/meshchatx/src/backend/database/messages.py
+++ b/meshchatx/src/backend/database/messages.py
@@ -5,43 +5,143 @@ from datetime import UTC, datetime
from .provider import DatabaseProvider
+_LXMF_UPSERT_FIELDS = (
+ "hash",
+ "source_hash",
+ "destination_hash",
+ "peer_hash",
+ "state",
+ "progress",
+ "is_incoming",
+ "method",
+ "delivery_attempts",
+ "next_delivery_attempt_at",
+ "title",
+ "content",
+ "fields",
+ "timestamp",
+ "rssi",
+ "snr",
+ "quality",
+ "is_spam",
+ "reply_to_hash",
+ "attachments_stripped",
+ "path_hops_at_send",
+ "path_interface_at_send",
+ "path_finding_measure",
+ "path_row_hash_hex",
+)
+_LXMF_OPTIONAL_UPSERT_FIELDS = frozenset({"attachments_stripped"})
+_LXMF_EXPORT_ONLY_KEYS = frozenset({"id", "lxmf_icon"})
+
class MessageDAO:
def __init__(self, provider: DatabaseProvider):
self.provider = provider
+ self._lxmf_columns_cache = None
+ self._lxmf_upsert_fields_cache = None
+
+ def _lxmf_table_columns(self):
+ if self._lxmf_columns_cache is None:
+ rows = self.provider.fetchall("PRAGMA table_info(lxmf_messages)")
+ self._lxmf_columns_cache = {row["name"] for row in rows}
+ return self._lxmf_columns_cache
+
+ def _lxmf_upsert_field_names(self):
+ if self._lxmf_upsert_fields_cache is None:
+ columns = self._lxmf_table_columns()
+ self._lxmf_upsert_fields_cache = [
+ field
+ for field in _LXMF_UPSERT_FIELDS
+ if field in columns or field not in _LXMF_OPTIONAL_UPSERT_FIELDS
+ ]
+ return self._lxmf_upsert_fields_cache
+
+ @staticmethod
+ def normalize_lxmf_message_for_import(data):
+ if not isinstance(data, dict):
+ data = dict(data)
+
+ row = {
+ key: value
+ for key, value in data.items()
+ if key not in _LXMF_EXPORT_ONLY_KEYS
+ }
+
+ message_hash = row.get("hash")
+ if not message_hash or not isinstance(message_hash, str):
+ return None
+
+ source_hash = row.get("source_hash")
+ destination_hash = row.get("destination_hash")
+ if not source_hash or not destination_hash:
+ return None
+
+ if not row.get("peer_hash"):
+ is_incoming = row.get("is_incoming")
+ row["peer_hash"] = (
+ source_hash if is_incoming in (1, True, "1") else destination_hash
+ )
+
+ fields_value = row.get("fields")
+ if isinstance(fields_value, dict):
+ row["fields"] = json.dumps(fields_value)
+ elif isinstance(fields_value, str):
+ stripped = fields_value.strip()
+ if stripped.startswith("{") or stripped.startswith("["):
+ try:
+ parsed = json.loads(stripped)
+ if isinstance(parsed, dict):
+ row["fields"] = json.dumps(parsed)
+ except json.JSONDecodeError:
+ pass
+
+ for key in ("is_incoming", "is_spam", "attachments_stripped"):
+ if key in row and isinstance(row[key], bool):
+ row[key] = int(row[key])
+
+ if row.get("progress") is None:
+ row["progress"] = 0.0
+
+ return row
+
+ def import_lxmf_messages(self, messages):
+ imported = 0
+ skipped = 0
+ errors = []
+
+ if not isinstance(messages, list):
+ raise ValueError("messages must be an array")
+
+ for index, message in enumerate(messages):
+ normalized = self.normalize_lxmf_message_for_import(message)
+ if normalized is None:
+ skipped += 1
+ continue
+ try:
+ self.upsert_lxmf_message(normalized)
+ imported += 1
+ except Exception as exc:
+ errors.append(
+ {
+ "index": index,
+ "hash": normalized.get("hash"),
+ "error": str(exc),
+ },
+ )
+
+ return {
+ "imported": imported,
+ "skipped": skipped,
+ "errors": errors,
+ }
def upsert_lxmf_message(self, data):
# Ensure data is a dict if it's a sqlite3.Row
if not isinstance(data, dict):
data = dict(data)
- # Ensure all required fields are present and handle defaults
- fields = [
- "hash",
- "source_hash",
- "destination_hash",
- "peer_hash",
- "state",
- "progress",
- "is_incoming",
- "method",
- "delivery_attempts",
- "next_delivery_attempt_at",
- "title",
- "content",
- "fields",
- "timestamp",
- "rssi",
- "snr",
- "quality",
- "is_spam",
- "reply_to_hash",
- "attachments_stripped",
- "path_hops_at_send",
- "path_interface_at_send",
- "path_finding_measure",
- "path_row_hash_hex",
- ]
+ fields = self._lxmf_upsert_field_names()
columns = ", ".join(fields)
placeholders = ", ".join(["?"] * len(fields))
@@ -67,15 +167,21 @@ class MessageDAO:
)
params = []
- for f in fields:
- val = data.get(f)
- if f == "fields" and isinstance(val, dict):
+ for field in fields:
+ val = data.get(field)
+ if field == "fields" and isinstance(val, dict):
val = json.dumps(val)
params.append(val)
now = datetime.now(UTC).isoformat()
- params.append(now)
- params.append(now)
+ created_at = data.get("created_at")
+ updated_at = data.get("updated_at")
+ if not isinstance(created_at, str) or not created_at.strip():
+ created_at = now
+ if not isinstance(updated_at, str) or not updated_at.strip():
+ updated_at = now
+ params.append(created_at)
+ params.append(updated_at)
self.provider.execute(query, params)
diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index 8a65c443..7a260c0c 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -50,6 +50,7 @@
@bulk-delete="onBulkDelete"
@export-folders="onExportFolders"
@import-folders="onImportFolders"
+ @messages-imported="onMessagesImported"
@toggle-conversation-pin="onToggleConversationPin"
@toggle-collapse="messagesListSidebarCollapsed = !messagesListSidebarCollapsed"
/>
@@ -1014,6 +1015,9 @@ export default {
input.click();
}
},
+ async onMessagesImported() {
+ await this.getConversations();
+ },
onFoldersImportFileSelected(event) {
const target = event.target;
const file = target.files?.[0];
diff --git a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
index 3227563f..e943d930 100644
--- a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
@@ -117,9 +117,13 @@
<div
v-if="tab === 'conversations'"
:class="[
- 'flex-1 flex flex-col bg-white dark:bg-zinc-950 border-gray-200 dark:border-zinc-800 overflow-hidden min-h-0',
+ 'relative flex-1 flex flex-col bg-white dark:bg-zinc-950 border-gray-200 dark:border-zinc-800 overflow-hidden min-h-0',
edgeBorderClass,
]"
+ @dragenter.prevent="onMessagesImportDragEnter"
+ @dragover.prevent="onMessagesImportDragOver"
+ @dragleave="onMessagesImportDragLeave"
+ @drop.prevent="onMessagesImportDrop"
>
<!-- Folders Section -->
<div class="border-b border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950">
@@ -640,6 +644,24 @@
</div>
</div>
</div>
+
+ <div
+ v-if="messageImportDragOver"
+ class="pointer-events-none absolute inset-0 z-50 flex items-center justify-center border-2 border-dashed border-indigo-500 bg-indigo-500/10"
+ >
+ <div class="px-4 text-center">
+ <MaterialDesignIcon
+ icon-name="import"
+ class="mx-auto size-8 text-indigo-600 dark:text-indigo-400"
+ />
+ <p class="mt-2 text-sm font-semibold text-indigo-700 dark:text-indigo-300">
+ {{ $t("maintenance.import_messages") }}
+ </p>
+ <p class="text-xs text-indigo-600/80 dark:text-indigo-400/80">
+ {{ $t("maintenance.import_messages_desc") }}
+ </p>
+ </div>
+ </div>
</div>
<!-- discover -->
@@ -788,6 +810,8 @@ import ContextMenuSectionLabel from "../contextmenu/ContextMenuSectionLabel.vue"
import GlobalState from "../../js/GlobalState";
import GlobalEmitter from "../../js/GlobalEmitter";
import MarkdownRenderer from "../../js/MarkdownRenderer";
+import ToastUtils from "../../js/ToastUtils";
+import { importMessagesFromFile } from "../../js/messageImport";
export default {
name: "MessagesSidebar",
@@ -896,6 +920,7 @@ export default {
"bulk-delete",
"export-folders",
"import-folders",
+ "messages-imported",
"toggle-conversation-pin",
"toggle-collapse",
],
@@ -930,6 +955,8 @@ export default {
},
draggedHash: null,
dragOverFolderId: null,
+ messageImportDragOver: false,
+ messageImportDragDepth: 0,
smUp: typeof window !== "undefined" ? window.innerWidth >= 640 : true,
conversationLongPressTimer: null,
conversationLongPressFired: false,
@@ -1161,6 +1188,60 @@ export default {
}
this.draggedHash = null;
},
+ isMessagesImportFileDrag(event) {
+ if (this.draggedHash) {
+ return false;
+ }
+ const dataTransfer = event.dataTransfer;
+ if (!dataTransfer) {
+ return false;
+ }
+ return Array.from(dataTransfer.types || []).includes("Files");
+ },
+ onMessagesImportDragEnter(event) {
+ if (!this.isMessagesImportFileDrag(event)) {
+ return;
+ }
+ this.messageImportDragDepth += 1;
+ this.messageImportDragOver = true;
+ },
+ onMessagesImportDragOver(event) {
+ if (!this.isMessagesImportFileDrag(event)) {
+ return;
+ }
+ event.dataTransfer.dropEffect = "copy";
+ },
+ onMessagesImportDragLeave() {
+ if (this.messageImportDragDepth > 0) {
+ this.messageImportDragDepth -= 1;
+ }
+ if (this.messageImportDragDepth === 0) {
+ this.messageImportDragOver = false;
+ }
+ },
+ async onMessagesImportDrop(event) {
+ this.messageImportDragDepth = 0;
+ this.messageImportDragOver = false;
+ if (!this.isMessagesImportFileDrag(event)) {
+ return;
+ }
+ const file = event.dataTransfer?.files?.[0];
+ if (!file) {
+ return;
+ }
+ const name = file.name.toLowerCase();
+ if (!name.endsWith(".json") && file.type !== "application/json") {
+ ToastUtils.error(this.$t("maintenance.import_failed"));
+ return;
+ }
+ try {
+ const { imported } = await importMessagesFromFile(file);
+ ToastUtils.success(this.$t("maintenance.import_success", { count: imported }));
+ this.$emit("messages-imported");
+ } catch {
+ ToastUtils.error(this.$t("maintenance.import_failed"));
+ }
+ },
async createFolder() {
const name = await DialogUtils.prompt(
this.$t("messages.enter_folder_name"),
diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index fdeb227a..18c92c53 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -2652,6 +2652,7 @@ import Utils from "../../js/Utils";
import WebSocketConnection from "../../js/WebSocketConnection";
import DialogUtils from "../../js/DialogUtils";
import ToastUtils from "../../js/ToastUtils";
+import { importMessagesFromFile } from "../../js/messageImport";
import DownloadUtils from "../../js/DownloadUtils";
import GlobalEmitter from "../../js/GlobalEmitter";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
@@ -4269,22 +4270,12 @@ export default {
const file = event.target.files[0];
if (!file) return;
- const reader = new FileReader();
- reader.onload = async (e) => {
- try {
- const data = JSON.parse(e.target.result);
- if (!data.messages) throw new Error("Invalid file format");
-
- await window.api.post("/api/v1/maintenance/messages/import", {
- messages: data.messages,
- });
- ToastUtils.success(this.$t("maintenance.import_success", { count: data.messages.length }));
- } catch {
- ToastUtils.error(this.$t("maintenance.import_failed"));
- }
- };
- reader.readAsText(file);
- // Reset input
+ try {
+ const { imported } = await importMessagesFromFile(file);
+ ToastUtils.success(this.$t("maintenance.import_success", { count: imported }));
+ } catch {
+ ToastUtils.error(this.$t("maintenance.import_failed"));
+ }
event.target.value = "";
},
async exportFolders() {
diff --git a/meshchatx/src/frontend/js/messageImport.js b/meshchatx/src/frontend/js/messageImport.js
new file mode 100644
index 00000000..0ec5bcff
--- /dev/null
+++ b/meshchatx/src/frontend/js/messageImport.js
@@ -0,0 +1,34 @@
+/**
+ * Parse and import LXMF message export JSON.
+ */
+
+export function parseMessagesImportJson(text) {
+ const data = JSON.parse(text);
+ const messages = Array.isArray(data) ? data : data?.messages;
+ if (!Array.isArray(messages)) {
+ throw new Error("Invalid file format");
+ }
+ return messages;
+}
+
+export async function importMessagesFromText(text) {
+ const messages = parseMessagesImportJson(text);
+ const response = await window.api.post("/api/v1/maintenance/messages/import", {
+ messages,
+ });
+ return {
+ messages,
+ imported: response.data?.imported ?? messages.length,
+ skipped: response.data?.skipped ?? 0,
+ };
+}
+
+export async function importMessagesFromFile(file) {
+ const form = new FormData();
+ form.append("file", file);
+ const response = await window.api.post("/api/v1/maintenance/messages/import-file", form);
+ return {
+ imported: response.data?.imported ?? 0,
+ skipped: response.data?.skipped ?? 0,
+ };
+}
diff --git a/tests/backend/fixtures/http_api_routes.json b/tests/backend/fixtures/http_api_routes.json
index 7deceeb0..94e22784 100644
--- a/tests/backend/fixtures/http_api_routes.json
+++ b/tests/backend/fixtures/http_api_routes.json
@@ -512,6 +512,10 @@
"method": "POST",
"path": "/api/v1/maintenance/messages/import"
},
+ {
+ "method": "POST",
+ "path": "/api/v1/maintenance/messages/import-file"
+ },
{
"method": "DELETE",
"path": "/api/v1/maintenance/stickers"
diff --git a/tests/backend/test_message_dao_extended.py b/tests/backend/test_message_dao_extended.py
index 2e177fd3..21fbf951 100644
--- a/tests/backend/test_message_dao_extended.py
+++ b/tests/backend/test_message_dao_extended.py
@@ -85,7 +85,97 @@ def test_mark_stuck_messages_repairs_previously_failed_incoming(real_db):
assert real_db.messages.get_lxmf_message_by_hash("a" * 32)["state"] == "generating"
+def test_normalize_lxmf_message_for_import_strips_export_metadata():
+ row = {
+ "id": 1,
+ "hash": "a" * 64,
+ "source_hash": "b" * 32,
+ "destination_hash": "c" * 32,
+ "state": "generating",
+ "is_incoming": 1,
+ "fields": "{}",
+ "created_at": "2026-05-09T01:12:37.063720+00:00",
+ "lxmf_icon": {"icon_name": "account"},
+ }
+ normalized = MessageDAO.normalize_lxmf_message_for_import(row)
+ assert normalized is not None
+ assert normalized["peer_hash"] == "b" * 32
+ assert "id" not in normalized
+ assert "lxmf_icon" not in normalized
+
+
+def test_import_lxmf_messages_skips_invalid_rows(real_db):
+ messages = [
+ {"hash": "a" * 64, "source_hash": "b" * 32, "destination_hash": "c" * 32},
+ {"hash": "", "source_hash": "b" * 32, "destination_hash": "c" * 32},
+ ]
+ result = real_db.messages.import_lxmf_messages(messages)
+ assert result["imported"] == 1
+ assert result["skipped"] == 1
+ assert result["errors"] == []
+
+
+def test_import_lxmf_messages_without_attachments_stripped_column(tmp_path):
+ db_path = str(tmp_path / "legacy.db")
+ provider = DatabaseProvider(db_path)
+ DatabaseSchema(provider).initialize()
+ provider.execute("ALTER TABLE lxmf_messages DROP COLUMN attachments_stripped")
+ db = Database(db_path)
+
+ result = db.messages.import_lxmf_messages(
+ [
+ {
+ "id": 1,
+ "hash": "a" * 64,
+ "source_hash": "b" * 32,
+ "destination_hash": "c" * 32,
+ "peer_hash": "b" * 32,
+ "state": "generating",
+ "progress": 0,
+ "is_incoming": 1,
+ "method": "opportunistic",
+ "fields": "{}",
+ "timestamp": 1773512977.322906,
+ "attachments_stripped": 0,
+ },
+ ],
+ )
+ assert result["imported"] == 1
+ assert result["errors"] == []
+
+
def test_upsert_lxmf_message(message_dao, mock_provider):
+ mock_provider.fetchall.return_value = [
+ {"name": field}
+ for field in (
+ "hash",
+ "source_hash",
+ "destination_hash",
+ "peer_hash",
+ "state",
+ "progress",
+ "is_incoming",
+ "method",
+ "delivery_attempts",
+ "next_delivery_attempt_at",
+ "title",
+ "content",
+ "fields",
+ "timestamp",
+ "rssi",
+ "snr",
+ "quality",
+ "is_spam",
+ "reply_to_hash",
+ "attachments_stripped",
+ "path_hops_at_send",
+ "path_interface_at_send",
+ "path_finding_measure",
+ "path_row_hash_hex",
+ "created_at",
+ "updated_at",
+ )
+ ]
data = {"hash": "hash1", "content": "hello", "fields": {"key": "val"}}
message_dao.upsert_lxmf_message(data)
diff --git a/tests/backend/test_messages_export.py b/tests/backend/test_messages_export.py
index 2d0aefaa..46441867 100644
--- a/tests/backend/test_messages_export.py
+++ b/tests/backend/test_messages_export.py
@@ -184,3 +184,141 @@ async def test_messages_export_without_icons(mock_rns_minimal, temp_dir):
data = json.loads(response.body)
assert len(data["messages"]) == 1
assert "lxmf_icon" not in data["messages"][0]
+
+
+@pytest.mark.asyncio
+async def test_messages_import_export_roundtrip(mock_rns_minimal, temp_dir):
+ with patch("meshchatx.meshchat.generate_ssl_certificate"):
+ app = ReticulumMeshChat(
+ identity=mock_rns_minimal,
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ )
+ app.database.messages.upsert_lxmf_message(
+ {
+ "hash": "40509ace976b2e572bff2944d60207218135ef69940a25a423e60615760e7e06",
+ "source_hash": "695ff7ffdd5f9ef1ae7772cfa2ecf028",
+ "destination_hash": "7cc8d66b4f6a0e0e49d34af7f6077b5a",
+ "peer_hash": "695ff7ffdd5f9ef1ae7772cfa2ecf028",
+ "state": "generating",
+ "progress": 0,
+ "is_incoming": 1,
+ "method": "opportunistic",
+ "delivery_attempts": 0,
+ "next_delivery_attempt_at": None,
+ "title": "",
+ "content": "i ran nomadnet on orange pi lite too",
+ "fields": "{}",
+ "timestamp": 1773512977.322906,
+ "rssi": None,
+ "snr": None,
+ "quality": None,
+ "is_spam": 0,
+ "reply_to_hash": None,
+ "attachments_stripped": 0,
+ },
+ )
+
+ export_handler = None
+ import_handler = None
+ for route in app.get_routes():
+ if (
+ route.path == "/api/v1/maintenance/messages/export"
+ and route.method == "GET"
+ ):
+ export_handler = route.handler
+ if (
+ route.path == "/api/v1/maintenance/messages/import"
+ and route.method == "POST"
+ ):
+ import_handler = route.handler
+ assert export_handler is not None
+ assert import_handler is not None
+
+ export_response = await export_handler(MagicMock())
+ export_data = json.loads(export_response.body)
+ assert len(export_data["messages"]) == 1
+ assert "lxmf_icon" not in export_data["messages"][0]
+
+ import_request = MagicMock()
+ import_request.json = MagicMock(
+ return_value={"messages": export_data["messages"]},
+ )
+ import_response = await import_handler(import_request)
+ assert import_response.status == 200
+ import_data = json.loads(import_response.body)
+ assert import_data["imported"] == 1
+ assert import_data["skipped"] == 0
+
+
+@pytest.mark.asyncio
+async def test_messages_import_file_upload(mock_rns_minimal, temp_dir):
+ with patch("meshchatx.meshchat.generate_ssl_certificate"):
+ app = ReticulumMeshChat(
+ identity=mock_rns_minimal,
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ )
+
+ import_file_handler = None
+ for route in app.get_routes():
+ if (
+ route.path == "/api/v1/maintenance/messages/import-file"
+ and route.method == "POST"
+ ):
+ import_file_handler = route.handler
+ break
+ assert import_file_handler is not None
+
+ payload = {
+ "messages": [
+ {
+ "hash": "40509ace976b2e572bff2944d60207218135ef69940a25a423e60615760e7e06",
+ "source_hash": "695ff7ffdd5f9ef1ae7772cfa2ecf028",
+ "destination_hash": "7cc8d66b4f6a0e0e49d34af7f6077b5a",
+ "peer_hash": "695ff7ffdd5f9ef1ae7772cfa2ecf028",
+ "state": "generating",
+ "progress": 0,
+ "is_incoming": 1,
+ "method": "opportunistic",
+ "fields": "{}",
+ "timestamp": 1773512977.322906,
+ },
+ ],
+ }
+ body = json.dumps(payload).encode("utf-8")
+
+ class _MultipartField:
+ name = "file"
+
+ def __init__(self, data):
+ self._data = data
+ self._offset = 0
+
+ async def read_chunk(self, size=1024 * 1024):
+ if self._offset >= len(self._data):
+ return b""
+ chunk = self._data[self._offset : self._offset + size]
+ self._offset += len(chunk)
+ return chunk
+
+ class _MultipartReader:
+ def __init__(self, field):
+ self._field = field
+ self._done = False
+
+ async def next(self):
+ if self._done:
+ return None
+ self._done = True
+ return self._field
+
+ request = MagicMock()
+ request.multipart = MagicMock(
+ return_value=_MultipartReader(_MultipartField(body)),
+ )
+
+ response = await import_file_handler(request)
+ assert response.status == 200
+ data = json.loads(response.body)
+ assert data["imported"] == 1
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────